admin / Strike
publicWeb-Based UK Cyber Compliance Tool with Reporting
Strike / StrikeXi v3 / backend / app / security.py
4497 B · main
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 | import base64 import io from datetime import datetime, timedelta, timezone import pyotp import qrcode from fastapi import Depends, HTTPException, status, Request from fastapi.security import OAuth2PasswordBearer from jose import jwt, JWTError from passlib.context import CryptContext from sqlalchemy.orm import Session from .config import settings from .database import get_db from . import models pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login") TOTP_ISSUER = "StrikeXi" COMMON_PASSWORDS = {"password", "admin", "12345678", "changeme", "strikexi"} def hash_password(password: str) -> str: return pwd_context.hash(password) def verify_password(plain: str, hashed: str) -> bool: return pwd_context.verify(plain, hashed) SPECIAL_CHARS = set("!@#$%^&*()-_=+[]{};:,.<>?/|\\`~'\"") def password_problem(password: str) -> str | None: """Return a human-readable problem string, or None if the password is OK. Policy (V2): at least 8 characters, including at least one capital letter and at least one special character. """ if not password or len(password) < 8: return "Password must be at least 8 characters long." if not any(c.isupper() for c in password): return "Password must include at least one capital letter." if not any(c in SPECIAL_CHARS for c in password): return "Password must include at least one special character." if password.lower() in COMMON_PASSWORDS: return "Password is too common; choose something stronger." return None def create_access_token(subject: str, scope: str = "access", minutes: int | None = None) -> str: exp_minutes = minutes if minutes is not None else settings.ACCESS_TOKEN_EXPIRE_MINUTES expire = datetime.now(timezone.utc) + timedelta(minutes=exp_minutes) payload = {"sub": subject, "scope": scope, "exp": expire} return jwt.encode(payload, settings.SECRET_KEY, algorithm=settings.ALGORITHM) # --------------------------------------------------------------------------- # MFA / TOTP helpers # --------------------------------------------------------------------------- def generate_mfa_secret() -> str: return pyotp.random_base32() def verify_totp(secret: str, code: str, window: int = 1) -> bool: if not secret or not code: return False code = str(code).strip().replace(" ", "") if not code.isdigit(): return False return bool(pyotp.TOTP(secret).verify(code, valid_window=window)) def provisioning_uri(username: str, secret: str) -> str: return pyotp.TOTP(secret).provisioning_uri(name=username, issuer_name=TOTP_ISSUER) def qr_data_uri(uri: str) -> str: """Return a base64 PNG data URI of the otpauth provisioning QR code.""" img = qrcode.make(uri) buf = io.BytesIO() img.save(buf, format="PNG") return "data:image/png;base64," + base64.b64encode(buf.getvalue()).decode("ascii") def get_current_user(token: str = Depends(oauth2_scheme), db: Session = Depends(get_db)) -> models.User: cred_exc = HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Could not validate credentials", headers={"WWW-Authenticate": "Bearer"}, ) try: payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM]) username = payload.get("sub") if username is None: raise cred_exc except JWTError: raise cred_exc if payload.get("scope") != "access": raise cred_exc user = db.query(models.User).filter(models.User.username == username).first() if not user or not user.is_active: raise cred_exc return user def require_admin(user: models.User = Depends(get_current_user)) -> models.User: """Dependency that allows only administrators.""" if user.role != "admin": raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Administrator access required") return user def client_ip(request: Request) -> str: fwd = request.headers.get("x-forwarded-for") if fwd: return fwd.split(",")[0].strip() return request.client.host if request.client else "unknown" def write_audit(db: Session, username: str | None, action: str, detail: str = "", ip: str = ""): entry = models.AuditLog(username=username, action=action, detail=detail, ip_address=ip) db.add(entry) db.commit() |